iT邦幫忙

2024 iThome 鐵人賽

DAY 9
1
Modern Web

為你自己寫 Vue Component系列 第 9

[為你自己寫 Vue Component] AtomicScrollbar

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicScrollbar

在專案中,你會如何設定 Scrollbar 的樣式呢?

當我們想要自定義網頁的 Scrollbar 時,最常見的方式之一就是用 CSS 改寫原生的 Scrollbar 樣式。儘管 CSS 可以做到 Scrollbar 的客製化,但也有不少限制。例如,跨瀏覽器問題:在不同瀏覽器上,我們需要使用不同的前綴甚至不同的方法來實現。

不過,有時候我們還是希望能有更高的可控性與跨瀏覽器的支援度,這時 <AtomicScrollbar> 元件可以滿足我們這些需求。

元件分析

元件架構

AtomicScrollbar 元件架構

  1. Thumb:滑鼠點擊拖曳的部分。
  2. Track:軌道,Thumb 的容器。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Scrollbar 元件是如何設計的。

Element Plus

<ElScrollbar height="400px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ElScrollbar>

Element Plus 提供 <ElScrollbar> 作為容器,高度可透過 props 傳入,這個高度用於定義 <ElScrollbar> 內部 Wrap 的大小。如果內容超出 Wrap,則會啟動 Scrollbar。此外,若希望自定義 Viewport 的 HTML tag,<ElScrollbar> 接受 tag 這個 props 來設定。

PrimeVue

<ScrollPanel style="width: 100%; height: 200px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ScrollPanel>

PrimeVue 的元件名稱為 <ScrollPanel>,使用方式與 Element Plus 基本相同。不同的是,Element Plus 的 height 設定是透過 props,並將其應用於 Wrap 上,而 PrimeVue 則是直接限制最外層元件的大小來達到效果。

Radix Vue

<ScrollAreaRoot style="--scrollbar-size: 10px">
  <ScrollAreaViewport>
    <ul :style="{ padding: '15px 20px' }">
      <li v-for="item in 20" :key="item">{{ item }}</li>
    </ul>
  </ScrollAreaViewport>
  <ScrollAreaScrollbar orientation="vertical">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
  <ScrollAreaScrollbar orientation="horizontal">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
</ScrollAreaRoot>

Radix Vue 的使用方式看似較為複雜,但這其實提供了一個明確的架構指南,讓我們能輕易看到 Scrollbar 元件的組成。經過封裝後,使用起來也和前兩者無異。自定義 tag 的部分,Radix Vue 幾乎所有元件都支援使用 as 來設定 HTML tag。

<ScrollArea class="h-[200px] w-[350px] rounded-md border p-4">
  <p>
    <!-- 略 -->
  </p>
</ScrollArea>

Radix Vue 與 Element Plus、PrimeVue,或是未提及的 Vuetify、Nuxt UI 不同。Radix Vue 僅提供最基本的樣式(甚至幾乎沒有樣式)與結構,讓使用者可以自由定義風格。

元件實作

在實作上,我們甚至不需要處理 Props,因此我們可以從 <template> 開始。

<div class="atomic-scrollbar">
  <div class="atomic-scrollbar__viewport">
    <slot name="default" />
  </div>

  <div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
    <div class="atomic-scrollbar__thumb" />
  </div>

  <div class="atomic-scrollbar__track atomic-scrollbar__track--horizontal">
    <div class="atomic-scrollbar__thumb" />
  </div>
</div>

在這個結構中,Root 是用來提供 Track 定位的容器;Viewport 則是承載內容的容器。這樣做可以防止 Track 元素影響使用者的內容與語義化結構。

由於我們目的是自己設計 Scrollbar,因此需要先用 CSS 隱藏瀏覽器原生的 Scrollbar。

.atomic-scrollbar {
  overflow: hidden;

  &__viewport {
    height: inherit;
    overflow: auto;
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }
  }
}

scrollbar-width: none; 可以隱藏瀏覽器的 Scrollbar,而 &::-webkit-scrollbar { display: none; } 則用於隱藏 iOS 瀏覽器的 Scrollbar。

接下來,我們讓 Track 被定位在 Root 的右側與下方,並加上樣式來美化 Scrollbar。

.atomic-scrollbar {
  position: relative;

  &__track {
    --atomic-scrollbar-track-size: 8px;
    position: absolute;
    background-color: transparent;
    border-radius: 10px;

    &--vertical,
    &--horizontal {
      right: 0;
      bottom: 0;
    }

    &--vertical {
      top: 0;
      width: var(--atomic-scrollbar-track-size);
    }

    &--horizontal {
      left: 0;
      height: var(--atomic-scrollbar-track-size);
    }

    &:hover {
      --atomic-scrollbar-track-size: 10px;
      background-color: rgba(lightgray, 0.2);
    }
  }

  &__thumb {
    background-color: rgba(gray, 0.2);
    border-radius: 10px;
  }

  &__track--vertical &__thumb {
    width: 100%;
  }

  &__track--horizontal &__thumb {
    height: 100%;
  }
}

樣式部分已經處理完畢,接下來進入較為複雜的數學計算。

計算 Thumb 的寬高

Scrollbar 分為 Y 軸與 X 軸,本篇文章以 Y 軸為範例。

首先,我們要根據 Viewport 高度與 Content(整個內容高度)的比例,計算出 Thumb 應該設置的高度。

AtomicScrollbar 比例關係

由圖片可以推導出這樣的比例關係:Content : Viewport = Track : Thumb

我們已知 Content、Viewport 和 Track 的高度,所以我們可以藉由國中數學的比例式運算,內項相乘=外項相乘,計算出 Thumb 應該要有的高度。

$$\text{Viewport} \times \text{Thumb} = \text{Content} \times \text{Track}$$

由於畫面上的 Viewport 高度等於 Track 高度,因此可以寫成:

const viewportRef = ref<HTMLElement>();

const thumbHeight = ref(0);
const thumbWidth = ref(0);

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth } = viewport;

  thumbHeight.value = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  thumbWidth.value = (offsetWidth * offsetWidth) / viewport.scrollWidth;
}

試著計算一下,假設 Content 高度為 2000,Viewport 高度為 500,那麼算出的 Thumb 高度應該是 (500 * 500) / 2000,等於 125

但如果 Thumb 算出的數值過小,可能會影響使用體驗,因此我們希望 Thumb 的最小高度不小於 20。

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth } = viewport;

  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;

  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);

  thumbHeight.value = height;
  thumbWidth.value = width;
}

不過這樣可能導致算出來得 Thumb 高度超過 Viewport,因此需要再進行微調。

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const offsetHeight = viewport.offsetHeight;
  const offsetWidth = viewport.offsetWidth;

  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
  
  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);

  thumbHeight.value = height < offsetHeight ? height : 0;
  thumbWidth.value = width < offsetWidth ? width : 0;
};

大多數情況下,Content : ViewportViewport : Thumb 的比例相等。也就是說,當 Viewport 往下滾動 10%,Thumb 也會往下 10%,此時移動比率(ratioY)為 1

但是當 originalHeight 小於 MIN_SIZE 時,我們會取用 MIN_SIZE,這樣前面的比例就會不相等。

因此,我們需要計算 Thumb 的移動比率:

let ratioY = 1;
let ratioX = 1;

const update = () => {
  // 略

  ratioY = originalHeight / (offsetHeight - originalHeight) / (height / (offsetHeight - height));
  ratioX = originalWidth / (offsetWidth - originalWidth) / (width / (offsetWidth - width));
};

用程式碼表達比較複雜,列成數學式會更易理解:

$$\text{ratioY} = \frac{\text{originalHeight}}{\left(\text{offsetHeight} - \text{originalHeight}\right)} \div \frac{\text{height}}{\left(\text{offsetHeight} - \text{height}\right)}$$

由於最終計算出的 height 只會小於或等於 offsetHeight,所以我們可以推算出 ratioY 的值會在小於等於 1 到大於 0 之間。

計算 Thumb 的位置

Thumb 的位置計算與之前類似,我們需要計算出 Thumb 的位置。

const thumbTop = ref(0);
const thumbLeft = ref(0);

const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;

  thumbTop.value = ((scrollTop * 100) / offsetHeight);
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth);
};

通過滾動距離與總滾動量的比例,可以計算出 Thumb 應該處於的位置。

$$\text{thumbTop} = \left(\frac{\text{scrollTop}}{\text{offsetHeight}}\right) \times 100$$

不過,還需要考慮到 Content : ViewportViewport : Thumb 比例不相等的情況,此時需要用到前面計算的 ratioYratioX

$$\text{thumbTop} = \left(\frac{\text{scrollTop}}{\text{offsetHeight}}\right) \times 100 \times \text{ratioY}$$

const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;

  thumbTop.value = ((scrollTop * 100) / offsetHeight) * ratioY;
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth) * ratioX;
};

更新 Thumb 的大小、比例與位置

了解如何計算 Thumb 的高度、比例與位置後,我們可以將這些數值應用到元件上。

<div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
  <div
    class="atomic-scrollbar__thumb"
    :style="{ 
      transform: `translateY(${thumbTop}%)`, 
      height: `${thumbHeight}px`
    }"
  />
</div>

接下來,我們來探討何時計算 Thumb 的大小與位置。

更新 Thumb 的大小與比例的時機包括:元件 mounted 後、Viewport 大小改變後、內容變更後。

const update = () => {
  // 略
};

const viewportRef = ref<HTMLElement>();

let unobserve: (() => void) | null = null;

onMounted(() => {
  const observer = new ResizeObserver(update);

  observer.observe(viewportRef.value);
  unobserve = () => observer.disconnect();
})

onUpdated(update);

onBeforeUnmount(() => {
  unobserve();
  unobserve = null;
})

大多數情況下,我強烈不建議在 onUpdated 中更新響應式資料,這很可能會導致無限更新循環的問題。

在這個元件中,我們更新 Thumb 的大小與比例後會再次觸發 onUpdated,但由於更新值與第一次相同,因此不會產生無限更新循環。

滑鼠點擊 Thumb 拖曳

在處理滑鼠點擊 Thumb 拖曳前,我們先來規劃整個流程:

  1. 滑鼠在 Thumb 上按下不放。
  2. 滑鼠移動時,Thumb 跟著移動。
  3. 滑鼠放開時,Thumb 停止移動。

因此,我們需要針對 Thumb 監聽 pointerdown 事件,並在按下後開始對整個網頁監聽 pointermove 來記錄移動量,並將其應用到 Viewport 上。同時,我們也需要監聽 pointerup 事件來移除監聽的 pointermovepointerup 事件。

對整個網頁監聽 pointermove 是為了確保滑鼠移出 Thumb 時仍能繼續拖曳。

onThumbPointerdown

onThumbPointerdown 的目的是記錄滑鼠按下的位置,並啟動對整個網頁的 pointermovepointerup 事件監聽。

let mousePosition: number = 0;
let scrollOffset: number | null = null;

const onThumbPointerdown = (event: PointerEvent) => {
  if (event.ctrlKey || event.button !== 0) return;

  const viewport = viewportRef.value;
  if (!viewport) return;

  mousePosition = event.pageY;
  scrollOffset = viewport.scrollTop;

  document.addEventListener('pointermove', onDocumentPointermove);
  document.addEventListener('pointerup', onDocumentPointerup);
};

onDocumentPointermove

onDocumentPointermove 中,我們計算滑鼠移動的距離,並將其反映到 Viewport 上。

const onDocumentPointermove = (event: PointerEvent) => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const offset = (event.pageY - mousePosition) / viewport.offsetHeight;

  viewport.scrollTop = scrollOffset! + offset * viewport.scrollHeight;
};

offset 是滑鼠移動距離相對於 Viewport 高度的比值:

$$\text{offset} = \frac{\text{event.pageY} - \text{mousePosition}}{\text{viewport.offsetHeight}}$$

將這個比值乘上 Viewport 的 scrollHeight,就能得到滑鼠移動所反映的移動距離,最後將移動距離與預先記錄的 scrollOffset 相加,就得到了最終的 scrollTop

$$\text{viewport.scrollTop} = \text{scrollOffset} + \text{offset} \times \text{viewport.scrollHeight}$$

這樣,我們就能在滑鼠移動時讓 Viewport 跟著移動了!

onDocumentPointerup

最後,當滑鼠放開時,我們要移除對 pointermovepointerup 的監聽,並清空暫存變數。

const onDocumentPointerup = () => {
  document.removeEventListener('pointermove', onDocumentPointermove);
  document.removeEventListener('pointerup', onDocumentPointerup);

  currentOrientation = null;
  scrollOffset = null;
  mousePosition = 0;

  if (document.onselectstart !== originalOnSelectStart) {
    document.onselectstart = originalOnSelectStart;
  }
};

我們來看看滑鼠點擊 Thumb 拖曳的效果。

AtomicScrollbar 滑鼠點擊 Thumb 拖曳

Thumb 拖曳的部分,我們需要考量到在行動裝置上操作,在行動裝置上沒有滑鼠,也就沒有 MouseEvent,這時我們使用 PointerEvent 會是更好的選擇。

滑鼠點擊 Track 跳到指定位置

當點擊原生 Scrollbar 的 Track 時,會逐步跳到指定位置。我們希望 <AtomicScrollbar> 也具備這樣的功能。此處實作一個簡單版本,當使用者點擊 Track 時,Thumb 會直接跳到指定位置。

我們可以換算出點擊 Track 的位置對應到 Content 上的位置,然後將 Viewport 移動到該位置。

例如:Track 高度為 100,Content 高度為 1000,點擊 Track 的位置為 20,則對應的 Content 位置為 200,這表示 Viewport 要移動到 200 的位置。

$$\text{ScrollTop} = \text{Point} \times \left(\frac{\text{Content}}{\text{Track}}\right)$$

Content 的高度剛好等於 Viewport 可滾動的高度,因此程式碼實作如下:

const onTrackPointerdown = (
  event: PointerEvent,
  orientation: OrientationKey
) => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const track = event.currentTarget as HTMLElement;

  const rect = track.getBoundingClientRect();
  const point = Math.abs(rect.top - event.pageY);

  viewport.scrollTop = point * (viewport.scrollHeight / track.offsetHeight);
};

這樣一來,我們就可以在點擊 Track 時,讓 Viewport 與 Thumb 移動到指定的位置。

AtomicScrollbar 滑鼠點擊 Track 跳到指定位置

沒有互動時隱藏 Scrollbar

最後,我們希望 Scrollbar 在沒有互動時可以隱藏。

互動行為包括:滑鼠進入 Thumb、滑鼠拖曳 Thumb、滑鼠滾動。因此,我們可以使用兩個 flag 變數來記錄這些狀態。

const active = ref(false);
const dragging = ref(false);

在拖曳過程中,dragging 設為 true,而滑鼠移動或滾動時,active 設為 true

dragging

const onDocumentPointermove = (event: PointerEvent) => {
  // 略

  dragging.value = true;
};

const onDocumentPointerup = () => {
  // 略

  dragging.value = false;
};

active

const scheduleHideScrollbar = () => {
  time && clearTimeout(time);
  time = setTimeout(() => {
    active.value = false;
  }, 1000);
};


const onScroll = () => {
  // 略

  scheduleHideScrollbar();
  active.value = true;
};

const onTrackPointerenter = () => {
  // 略

  active.value = true;
};

const onTrackPointerleave = scheduleHideScrollbar;

最後,在 Track 上加上 v-show 條件,當 activedraggingtrue 時顯示,否則隱藏。

<div
  v-show="active || dragging"
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
>
  <!-- 略 -->
</div>

進階功能

減少重複的 ResizeObserver

目前每一個 Scrollbar 都會有一個獨立的 ResizeObserver。如果畫面上同時有很多 <AtomicScrollbar> 存在,將造成不少的資源浪費。

我們可以使用在 <AtomicLink> 裡面用過的「單例模式」,讓每個 <AtomicScrollbar> 都共享同一個 ResizeObserver 實例。

let unobserve: (() => void) | null = null;

onMounted(() => {
  const observer = createResizeObserver();
  unobserve = observer.observe(viewportRef.value, update);
});

onBeforeUnmount(() => {
  unobserve?.();
  unobserve = null;
});

createResizeObserver 的實作如下:

type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;

let cache: { observe: ObserveFn } | undefined;

export default function createResizeObserver() {
  if (cache) return cache;

  let observer: ResizeObserver | null = null;
  const callbacks = new Map<Element, CallbackFn>();

  const observe: ObserveFn = (element, callback) => {
    if (!observer) {
      observer = new ResizeObserver(entries => {
        for (const entry of entries) {
          const callback = callbacks.get(entry.target);
          callback?.();
        }
      });
    }

    callbacks.set(element, callback);
    observer.observe(element);

    return () => {
      callbacks.delete(element);
      observer!.unobserve(element);

      if (callbacks.size === 0) {
        observer!.disconnect();
        observer = null;
      }
    };
  };

  return (cache = { observe });
}

<AtomicLink> 裡面實作的 createIntersectionObserver 方式相同,第一層的 cache 確保無論呼叫多少次 createResizeObserver 都只會在第一次建立一個 { observe } 物件並在之後共用;第二層的單例模式則確保無論呼叫多少次 observe,都只會有一個 ResizeObserver 實例。

詳細拆解說明可以參考 AtomicLink 中的「減少重複的 IntersectionObserver」章節。

這樣,我們就可以減少重複的 ResizeObserver 實例,提高效能。

無障礙

對於我們看得見的人來說,畫面上那條小小的東西很容易被認為是 Scrollbar,但對於使用螢幕閱讀器的人來說,這些東西可能不易辨識。因此,我們需要透過 role 來明確告知這是一個 Scrollbar。

角色 Role

首先,我們需要為 Track 加上 role="scrollbar" 屬性,這樣螢幕閱讀器才能識別這是一個 Scrollbar。

<div
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
  role="scrollbar"
>
  <div
    class="atomic-scrollbar__thumb"
    :style="{
      transform: `translateY(${thumbTop}%)`,
      height: `${thumbHeight}px`,
    }"
  />
</div>

ARIA 屬性

為了配合 role="scrollbar",我們還需要加上一些 aria-* 屬性,這樣螢幕閱讀器可以清楚讀取 Scrollbar 的狀態。

  • aria-controls:指定 Scrollbar 控制的元素。
  • aria-orientation:指定 Scrollbar 的方向。
  • aria-valuenow:指定 Scrollbar 的當前值。
  • aria-valuemax:指定 Scrollbar 的最大值,預設為 100。
  • aria-valuemin:指定 Scrollbar 的最小值,預設為 0。

我們需要計算 aria-valuenow 的值。不同於 thumbTop 是 Thumb 移動的百分比,aria-valuenow 表示已滾動的百分比。

最簡單的算法如下:

const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;

  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});

但是這樣的數據不會隨著滾動自動更新,因為 scrollTopoffsetHeightscrollHeight 都不是 Vue 追蹤的響應式變數。此時,我們可以使用一個小技巧來讓 valuenowY 隨著 Scrollbar 滾動更新。

const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;

  // 追蹤依賴
  void thumbTop.value;

  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});

$$\text{valuenowY} = \frac{\text{scrollTop}}{\text{scrollHeight} - \text{offsetHeight}} \times 100$$

computed 會在第一次運算時,收集函數中的響應式資料(如 refreactivecomputed),當這些依賴變化時,computed 會重新計算。

thumbTop.value 是響應式變數,會隨著 Scrollbar 滾動更新。使用 void thumbTop.value 可以使 thumbTop.value 的變動觸發 valuenowY 的重新計算,從而達到追蹤當前滾動量的效果。

總結

在製作 <AtomicScrollbar> 的過程中,我們進行了許多數學運算,計算了 Thumb 的高度、寬度與位置,並計算了滑鼠拖曳 Thumb 的移動量。最後,在實現無障礙功能時也用到了數學運算。如果一次看不懂,建議帶入一些具體的數字來計算,這樣更容易理解。

Scrollbar 雖然不是常見的元件,但相比純 CSS 處理 Scrollbar,使用 <AtomicScrollbar> 元件可以在不同作業系統和瀏覽器上達到一致的效果,特別是解決了 Windows 上 Scrollbar 寬度約 17px 的問題。

雖然我們已盡力模擬瀏覽器原生 Scrollbar 的行為,但例如在行動裝置上,點擊 Scrollbar 的震動回饋等功能尚未完全普及(特別是在 Safari 上)。因此,使用前建議與合作夥伴充分理解這些方案的優缺點,以找到最適合的解決方案。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicDropdown
下一篇
[為你自己寫 Vue Component] AtomicAccordion / AtomicCollapse
系列文
為你自己寫 Vue Component19
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言